Перейти к основному содержимому

5.16. Управляющие конструкции и операторы

Разработчику Архитектору

Управляющие конструкции и операторы

Программы на языке ассемблера управляют последовательностью выполнения команд через прямое манипулирование адресами памяти и состоянием процессора. В отличие от высокоуровневых языков, где логика ветвления и повторения выражается с помощью ключевых слов вроде if, while или for, ассемблер предоставляет минимальный, но мощный набор инструкций, позволяющих строить любую вычислительную логику. Эти инструкции работают с регистрами флагов, указателями на команды и метками, задавая точные точки перехода в исполняемом коде.

Центральным элементом управления потоком исполнения в ассемблере является указатель команд — специальный регистр процессора, хранящий адрес следующей инструкции, которую должен выполнить процессор. По умолчанию после каждой команды указатель увеличивается на длину текущей инструкции, обеспечивая линейное выполнение программы. Однако управляющие конструкции позволяют изменять значение этого указателя принудительно, направляя выполнение в другую часть кода. Такие действия называются переходами, и они лежат в основе всех условных и циклических структур.

Условные переходы: JZ, JNZ, JE, JNE

Условные переходы — это инструкции, которые изменяют указатель команд только в том случае, если определённое условие истинно. Эти условия проверяются по состоянию флагов — битов в специальном регистре флагов процессора (например, FLAGS в x86). Флаги устанавливаются автоматически большинством арифметических и логических операций, таких как CMP, SUB, ADD, TEST.

Одна из самых часто используемых инструкций для подготовки условий — CMP (compare). Она выполняет вычитание двух операндов, но не сохраняет результат; вместо этого она лишь обновляет флаги, отражая, был ли результат нулём, положительным, отрицательным, вызвал ли переполнение и так далее. После этого программа может использовать условный переход, чтобы отреагировать на состояние этих флагов.

Инструкция JZ (Jump if Zero) передаёт управление на указанную метку, если флаг нуля (Zero Flag, ZF) установлен. Этот флаг активируется, когда результат последней операции равен нулю. Например, после сравнения двух одинаковых чисел (CMP AX, BX, где AX = BX) флаг ZF будет установлен, и JZ выполнит переход.

Инструкция JNZ (Jump if Not Zero) делает противоположное: она выполняет переход, если флаг нуля сброшен. Это полезно, когда требуется продолжить выполнение только при неравенстве значений.

Инструкции JE (Jump if Equal) и JNE (Jump if Not Equal) являются синонимами JZ и JNZ соответственно. Они используются ради читаемости: когда программа сравнивает значения на равенство, использование JE делает намерение автора более очевидным. Процессор интерпретирует их одинаково — через проверку флага ZF.

Все эти инструкции работают с относительными смещениями. Это означает, что в машинном коде после самой инструкции перехода следует число, указывающее, на сколько байт вперёд или назад нужно сместить указатель команд. Ассемблер автоматически вычисляет это смещение на этапе компиляции, когда программист указывает имя метки. Метка — это просто символическое имя для адреса в коде, например:

start:
mov ax, 5
cmp ax, 5
je equal_case
; код для неравенства
jmp end
equal_case:
; код для равенства
end:
; завершение

Здесь je equal_case приведёт к тому, что если AX равно 5, выполнение перейдёт к блоку под меткой equal_case. В противном случае процессор продолжит выполнение следующей строки.

Условные переходы ограничены по дальности: в большинстве архитектур они могут переходить только в пределах короткого диапазона (обычно ±127 байт для коротких переходов). Если цель находится дальше, ассемблер автоматически выбирает длинную форму инструкции или требует явного указания типа перехода. Это техническая деталь, но она важна при ручной оптимизации кода.


Циклы через метки и декремент с проверкой

Циклы в ассемблере реализуются вручную, без использования специализированных ключевых слов или автоматических конструкций. Программист сам управляет счётчиком итераций, организует проверку условия продолжения цикла и обеспечивает возврат к началу тела цикла. Такой подход даёт полный контроль над каждым этапом выполнения и позволяет оптимизировать циклы под конкретные задачи и архитектурные особенности процессора.

Типичная структура цикла включает три компонента: инициализацию счётчика, тело цикла и логику завершения. Инициализация часто происходит до входа в цикл — например, загрузка числа итераций в регистр:

mov cx, 10   ; CX будет счётчиком — 10 итераций

Далее следует метка, обозначающая начало тела цикла:

loop_start:
; здесь размещается код, который должен повторяться

После выполнения тела цикла необходимо уменьшить значение счётчика и проверить, достигло ли оно нуля. Для этого часто используется комбинация инструкций DEC (декремент) и условного перехода:

    dec cx          ; уменьшаем счётчик на единицу
jnz loop_start ; если результат не ноль — возвращаемся к началу

Эта последовательность образует замкнутый контур: пока счётчик не обнулится, управление будет возвращаться к метке loop_start. Как только CX станет равным нулю, флаг ZF установится, инструкция jnz не выполнит переход, и программа продолжит выполнение за пределами цикла.

Такой паттерн — декремент с последующей проверкой на ноль — является одним из самых распространённых способов организации циклов в ассемблере. Он эффективен, потому что инструкция DEC автоматически обновляет флаг нуля, что делает отдельную команду сравнения (CMP) избыточной. Это экономит один такт процессора и уменьшает размер кода.

В некоторых архитектурах существует даже специальная инструкция для циклов — LOOP. Она автоматически уменьшает значение регистра CX (или ECX/RCX в 32- и 64-битных режимах) и выполняет переход на указанную метку, если результат не нулевой. Пример:

mov cx, 5
my_loop:
; тело цикла
loop my_loop

Хотя LOOP выглядит удобно, на современных процессорах она часто работает медленнее, чем явная комбинация DEC + JNZ, из-за особенностей конвейерной обработки команд. Поэтому в производственном коде предпочтение отдаётся ручному управлению циклом.

Циклы не обязательно должны использовать счётчики. Они могут основываться на других условиях — например, на значении флага, содержимом памяти или результате сравнения двух переменных. В таких случаях программист использует другие условные переходы, такие как JG (Jump if Greater), JL (Jump if Less), JB (Jump if Below — для беззнаковых сравнений) и их аналоги. Каждый из этих переходов проверяет комбинацию флагов, установленных после операции сравнения, и принимает решение о переходе на основе знака, переполнения или переноса.

Важной особенностью циклов в ассемблере является необходимость аккуратного управления регистрами и памятью внутри тела цикла. Поскольку нет автоматической изоляции переменных или областей видимости, любое изменение регистра может повлиять на логику завершения цикла. Программист обязан сохранять критические значения (например, внешние счётчики или указатели) при необходимости, используя стек или временные ячейки памяти.

Циклы могут быть вложенными. В этом случае каждый уровень вложенности требует собственного счётчика или механизма проверки условия. Регистры часто распределяются так, чтобы внешний цикл использовал один регистр (например, CX), а внутренний — другой (DX). При нехватке регистров применяется сохранение значений в памяти между итерациями.

Таким образом, циклы в ассемблере — это не абстракция, а явная последовательность команд, построенная вокруг переходов и изменения состояния процессора. Эта прозрачность позволяет добиваться максимальной производительности, но требует от программиста глубокого понимания потока управления и побочных эффектов каждой инструкции.


Вызовы подпрограмм: CALL и RET

Подпрограммы — это автономные фрагменты кода, предназначенные для многократного использования. Они позволяют избежать дублирования логики, улучшают читаемость программы и способствуют модульной организации кода. В ассемблере подпрограммы реализуются через две ключевые инструкции: CALL и RET.

Инструкция CALL выполняет два действия одновременно. Во-первых, она сохраняет адрес следующей команды — то есть точку возврата — в стеке. Во-вторых, она передаёт управление на указанную метку, которая обозначает начало подпрограммы. Стек здесь играет роль временного хранилища, гарантирующего, что после завершения подпрограммы программа сможет корректно вернуться туда, откуда была вызвана.

Например:

    call print_message
; продолжение основной программы

При выполнении call print_message процессор помещает в стек адрес первой инструкции после call (то есть адрес строки с комментарием «продолжение основной программы»), а затем переходит к метке print_message. Всё, что находится между этой меткой и инструкцией ret, составляет тело подпрограммы.

Инструкция RET завершает выполнение подпрограммы. Она извлекает ранее сохранённый адрес из стека и загружает его в указатель команд. В результате управление возвращается в точку, непосредственно следующую за вызовом call. Это обеспечивает плавное продолжение основной программы без разрывов логики.

Подпрограммы могут принимать входные данные и возвращать результаты. Поскольку ассемблер не имеет встроенной системы параметров, как в высокоуровневых языках, передача данных осуществляется через регистры, память или стек. Наиболее распространённый подход — использование регистров. Например, перед вызовом подпрограммы вычисляемое значение помещается в регистр AX, а подпрограмма читает его оттуда:

    mov ax, 42
call square
; теперь AX содержит результат

Внутри подпрограммы square может выполняться умножение AX на самого себя, и результат остаётся в том же регистре. Это соглашение — часть так называемого вызывающего соглашения (calling convention), которое определяет, какие регистры используются для аргументов, какие должны сохраняться вызываемой функцией, и где размещается возвращаемое значение.

Некоторые подпрограммы работают с данными, расположенными в памяти. В этом случае вызывающая сторона передаёт адрес данных (например, строки или массива) в регистре, а подпрограмма обращается к памяти по этому адресу. Такой подход позволяет обрабатывать объёмы данных, превышающие размер регистров.

Стек также может использоваться для передачи параметров. Перед вызовом call аргументы последовательно помещаются в стек с помощью инструкции PUSH. Подпрограмма затем считывает их, используя относительные смещения от текущего значения указателя стека (SP, ESP или RSP). После завершения подпрограммы ответственность за очистку стека может лежать либо на вызывающей стороне, либо на самой подпрограмме — это зависит от принятого соглашения о вызовах.

Вложенные вызовы подпрограмм полностью поддерживаются. Каждый вызов call добавляет новый адрес возврата в стек, а каждый ret извлекает верхний. Таким образом, стек автоматически отслеживает цепочку возвратов, даже если одна подпрограмма вызывает другую, та — третью, и так далее. Глубина вложенности ограничена только размером стека.

Особое внимание требуется при работе с рекурсией — когда подпрограмма вызывает саму себя. В этом случае каждый уровень рекурсии должен иметь собственное пространство для локальных данных, чтобы не перезаписывать значения предыдущих вызовов. Обычно это достигается путём резервирования места в стеке при входе в подпрограмму (например, вычитанием из SP) и освобождения этого места перед ret.

Подпрограммы также могут взаимодействовать с внешними библиотеками или системными сервисами. В таких случаях метка после call указывает не на локальный адрес, а на точку входа в системный API или динамическую библиотеку. Механизм вызова остаётся тем же, но соглашения о регистрах и стеке строго регламентируются операционной системой.

Таким образом, механизм CALL/RET предоставляет универсальный и надёжный способ структурирования кода. Он лежит в основе всех сложных программ, написанных на ассемблере, и служит фундаментом для более высокоуровневых абстракций, таких как функции и методы. Эффективное использование подпрограмм требует понимания работы стека, соглашений о вызовах и дисциплины в управлении состоянием процессора.